Docly Developers
Knowledge base Best practice Recommended

Protect every form with Turnstile

In shortEvery form in Docly is protected with Cloudflare Turnstile - all of them. Use the built-in form submission: Docly mails every submission to the workspace owner (data-smtp only chooses which SMTP configuration sends it), and data-validate runs a server-side function in #/API/ before the form is accepted. Do not hand-write a submit endpoint; that reimplements the platform badly. The only code you write is the validation function, which reads form["cf-turnstile-response"], posts it to Cloudflare with httpFormPost (URL-encoded, not JSON) and throws on failure. Site key in markup, secret under #/ which is never served. Cloudflare's test keys accept everything, so throw while one is in place - otherwise the form looks protected, blocks nothing, and your own test submissions reach the customer. Submissions are never stored in the file tree. Allowing challenges.cloudflare.com in script-src, connect-src and frame-src does not cost the A+ rating.
Applies to FormsSecurity
  1. What you'll see

    A site has a contact form, a booking form, a newsletter signup — anything that accepts input from an anonymous visitor and turns it into an email, a stored document or an outbound notification. Without bot protection it will be found and abused, usually within weeks of launch. The mailbox fills with junk, the customer stops reading it, and a real enquiry gets lost.

  2. What's actually happening

    Every form in Docly is protected with Cloudflare Turnstile. Not the ones that look attractive to spammers — all of them. A form without it is not finished.

    Use the built-in form submission. Do not write your own endpoint.

    Docly submits and mails forms for you. Two attributes on the <form> do the whole job:

    • Docly sends every submission by email to the workspace owner. The optional data-smtp="#/MySmtpConfig" points to an “SMTP configuration” document and only chooses which SMTP server sends it — it is not the recipient. An email address in data-smtp makes every submission fail with “Send failed due to configuration error”.
    • data-validate="Turnstile" — Docly calls #/API/Turnstile.js server-side before accepting the form. Throw to reject, return true to accept.

    This is the mechanism. Writing a custom API endpoint that reads form, verifies the token and calls docly.sendForm() reimplements it by hand, badly: you own the validation ordering, the redirects and the error paths, and you will get one of them wrong. sendForm() exists for the case where you are already inside an API function for another reason — it is not the route for an ordinary contact form.

    The validation function is the only code you write

    It lives at #/API/<Name>.js, matching the value of data-validate. It reads the token from form["cf-turnstile-response"], posts it to Cloudflare with docly.httpFormPost — the endpoint expects URL-encoded, not JSON — and throws on failure.

    Keys, and the trap in the test keys

    The site key is public and belongs in the markup. The secret key is not, and belongs in #/Config, the standard place for configuration, which is never served over HTTP — see Put configuration and secrets in the Config document.

    Cloudflare publishes test keys that always pass. A form running on the always-pass secret looks protected, tests green, and blocks nothing. Make the validation function fail closed while a test key is in place — throw rather than accept. Otherwise your own test submissions reach the customer''s mailbox, because the success path mails a real recipient.

    Never store submissions in the file tree

    A form submission is a message, not content. The built-in submission mails it. Files written into the workspace — even under #/ — accumulate, hold personal data, and nothing prunes them.

    What this costs in the Content-Security-Policy

    Turnstile loads a script and runs in an iframe from challenges.cloudflare.com, so that host has to be allowed. This does not cost the A+ rating: the policy still has no unsafe-inline and no unsafe-eval. Allowing one named host is not the same as opening the policy.

  3. What to do

    1. Keys in #/Config

    Configuration goes in the Config document, not in an ad-hoc JSON file — see Put configuration and secrets in the Config document. Two fields, the secret using Type: "password":

    CF_Turnstile_Site_Key     0x4AAAAAAA...   (public, goes in markup)
    CF_Turnstile_Secret_Key   0x4AAAAAAA...   (secret, API function only)

    2. The form — built-in submission, no custom endpoint

    #{ var cfg = docly.getJson("#/Config") || {}; }#
    
    <form id="ContactForm"
          data-validate="Turnstile">
    
      <input type="text" name="Name" placeholder="Name" required>
      <input type="text" name="Email" placeholder="Email" required>
      <textarea name="Message" placeholder="Your message" required></textarea>
    
      <div class="cf-turnstile" data-sitekey="#docly.htmlAttributeEncode(cfg.CF_Turnstile_Site_Key)#"></div>
      <button type="submit">Send</button>
    </form>
    
    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

    The form needs an id — Docly finds the form by it when the submission arrives. The email subject is set by Docly from the page title and the form id. Add data-smtp="#/MySmtpConfig" only if the mail should be sent through your own SMTP server; the recipient is always the workspace owner.

    The widget injects a hidden cf-turnstile-response field. The script tag is external — never inline.

    On a page that uses a master, the script tag needs an explicit transform or the merge drops it silently:

    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js"
            async defer xdt:Transform="Insert"></script>

    Without it the widget renders, no script loads, no token is produced, and the form fails validation with no visible cause. Check the served HTML for the script tag, not just the widget div.

    3. #/API/Turnstile.js

    export default function () {
        var cfg = docly.getJson("#/Config") || {};
    
        // Cloudflare's test keys accept everything. Fail closed, not open.
        var secret = cfg.CF_Turnstile_Secret_Key;
        if (!secret || secret.indexOf("1x00000000") === 0) {
            throw new Error("Turnstile is not configured with real keys.");
        }
    
        var token = form["cf-turnstile-response"];
        if (typeof token !== "string" && token && typeof token.length === "number") {
            token = token.length > 0 ? token[0] : "";
        }
        if (!token) { throw new Error("Missing Turnstile token."); }
    
        // httpFormPost, not httpPost — siteverify expects URL-encoded, not JSON.
        var res = docly.httpFormPost("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
            secret: secret,
            response: String(token)
        });
    
        if (!res || res.success !== true) { throw new Error("Turnstile validation failed."); }
    
        return true;
    }

    4. Content-Security-Policy

    In #/site.json, add the host to script-src, connect-src and frame-src. Nothing else changes:

    "Content-Security-Policy": "default-src ''self''; script-src ''self'' https://challenges.cloudflare.com; style-src ''self''; img-src ''self'' data:; font-src ''self''; connect-src ''self'' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action ''self''; frame-ancestors ''none''; base-uri ''self''; object-src ''none''; upgrade-insecure-requests"

    Testing without mailing the customer

    The success path mails a real recipient. Test the rejection paths — missing token, invalid token, missing required field. Leave the success path until the customer is ready — a successful submission is mailed to the workspace owner, so test in a workspace you own first. Failing closed on the test key prevents the accident as well as the spam.

    Checklist

    1. Built-in submission (form with an id) — no hand-written submit endpoint. data-smtp, if used, names an SMTP configuration, not an address.
    2. data-validate pointing at a validation function in #/API/.
    3. Secret under #/, site key in markup, script tag external.
    4. Validation throws while a test key is in place.
    5. Nothing written into the file tree.
    6. challenges.cloudflare.com in script-src, connect-src, frame-src; still no unsafe-inline.
    7. Rejection paths tested; success path not fired at the customer.

Last reviewed 26 Aug 2026